skip to content
luminary.blog
by Oz Akan
svelte 5 framework sketch

Svelte 5 Refresher with Runes

Comprehensive refresher for Svelte 5 with runes, reactivity, and component patterns

/ 12 min read

Table of Contents

What is Svelte?

Svelte is a compile-time framework that builds highly optimized vanilla JavaScript at build time, rather than shipping a large runtime like React or Vue. It offers a simple, intuitive syntax and excellent performance.

Svelte 5 introduces runes, a new way for reactivity that provides more explicit control and better performance.

Key Advantages

  • No virtual DOM: Svelte compiles components to efficient imperative code that directly manipulates the DOM
  • No runtime: React/Vue ship a runtime library (~40-100KB), Svelte compiles to vanilla JavaScript
  • Smaller bundles: Svelte apps are typically smaller and faster
  • Simpler syntax: Less boilerplate, no hooks or lifecycle complexity
  • True reactivity: Updates are surgical, not diffing-based

Basic Component Structure

App.svelte
<script>
// Component logic with runes
let name = $state('World');
let count = $state(0);
function increment() {
count += 1;
}
</script>
<!-- Markup goes here -->
<h1>Hello {name}!</h1>
<button onclick={increment}>
Clicked {count} times
</button>
<!-- Styles go here -->
<style>
h1 {
color: purple;
}
</style>

Reactivity with Runes

$state Rune

The $state rune creates reactive state that automatically updates the UI when changed.

<script>
let count = $state(0);
// No need for reactive statements - just use regular variables
let doubled = $derived(count * 2);
// Use $effect for side effects
$effect(() => {
console.log('Count is now:', count);
if (count > 10) {
console.log('Count is getting high!');
}
});
</script>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onclick={() => count++}>+</button>

$state with Arrays and Objects

With Svelte 5, you can mutate arrays and objects directly - no need for reassignment!

<script>
let numbers = $state([1, 2, 3]);
let user = $state({ name: 'John', age: 30 });
function addNumber() {
// Direct mutation works!
numbers.push(numbers.length + 1);
}
function updateAge() {
// Direct property mutation works!
user.age += 1;
}
</script>

$derived Rune

The $derived rune creates computed values that automatically update when dependencies change.

<script>
let firstName = $state('John');
let lastName = $state('Doe');
// Automatically recomputes when firstName or lastName changes
let fullName = $derived(`${firstName} ${lastName}`);
let numbers = $state([1, 2, 3, 4, 5]);
let total = $derived(numbers.reduce((sum, n) => sum + n, 0));
</script>
<p>Full name: {fullName}</p>
<p>Total: {total}</p>

$effect Rune

The $effect rune runs side effects when dependencies change.

<script>
let count = $state(0);
// Runs whenever count changes
$effect(() => {
console.log(`Count is ${count}`);
document.title = `Count: ${count}`;
});
// Cleanup function
$effect(() => {
const interval = setInterval(() => {
console.log('Tick');
}, 1000);
return () => {
clearInterval(interval);
};
});
</script>

Props

$props Rune

In Svelte 5, props are declared using the $props rune instead of export let.

Child.svelte
<script>
let { name, age = 25, isActive = false } = $props();
</script>
<p>Name: {name}</p>
<p>Age: {age}</p>
<p>Status: {isActive ? 'Active' : 'Inactive'}</p>
Parent.svelte
<script>
import Child from './Child.svelte';
</script>
<Child name="Alice" age={30} isActive />

Prop Spreading and Rest Props

Child.svelte
<script>
// Get specific props and collect the rest
let { name, age, ...rest } = $props();
</script>
<p>Name: {name}</p>
<p>Age: {age}</p>
<p>Other props: {JSON.stringify(rest)}</p>
Parent.svelte
<script>
import Child from './Child.svelte';
let user = { name: 'Bob', age: 25, isActive: true, role: 'admin' };
</script>
<Child {...user} />

TypeScript with $props

Child.svelte
<script lang="ts">
interface Props {
name: string;
age?: number;
isActive?: boolean;
}
let { name, age = 25, isActive = false }: Props = $props();
</script>

Event Handling

Basic Events

In Svelte 5, event handlers use the standard HTML attribute names (e.g., onclick instead of on:click).

<script>
function handleClick() {
alert('Button clicked!');
}
function handleMouseOver(event) {
console.log('Mouse over:', event.target);
}
</script>
<button onclick={handleClick}>Click me</button>
<div onmouseover={handleMouseOver}>Hover me</div>
<!-- Inline handlers -->
<button onclick={() => alert('Inline!')}>Inline</button>

Event Modifiers

Event modifiers are handled manually in Svelte 5:

<script>
function handleSubmit(event) {
event.preventDefault();
// Handle form submission
}
function handleInnerClick(event) {
event.stopPropagation();
console.log('inner');
}
</script>
<!-- Prevent default -->
<form onsubmit={handleSubmit}>
<input type="text" />
<button type="submit">Submit</button>
</form>
<!-- Stop propagation -->
<div onclick={() => console.log('outer')}>
<button onclick={handleInnerClick}>
Click me
</button>
</div>

Custom Events with Callbacks

Instead of dispatching custom events, Svelte 5 uses callback props:

Child.svelte
<script>
let { onMessage } = $props();
</script>
<button onclick={() => onMessage?.('Hello from child!')}>
Send Message
</button>
Parent.svelte
<script>
import Child from './Child.svelte';
function handleMessage(text) {
console.log('Received:', text);
}
</script>
<Child onMessage={handleMessage} />

Bindings

Input Bindings

Input bindings work the same way in Svelte 5, but state is declared with $state:

<script>
let name = $state('');
let age = $state(0);
let selected = $state('');
let checked = $state(false);
let group = $state([]);
</script>
<!-- Text input -->
<input bind:value={name} placeholder="Enter name" />
<!-- Number input -->
<input type="number" bind:value={age} />
<!-- Select -->
<select bind:value={selected}>
<option value="">Choose...</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
<!-- Checkbox -->
<input type="checkbox" bind:checked />
<!-- Radio group -->
<input type="radio" bind:group value="option1" />
<input type="radio" bind:group value="option2" />

Element Bindings

<script>
let element = $state();
let scrollY = $state(0);
let innerWidth = $state(0);
</script>
<!-- Bind to element reference -->
<div bind:this={element}>
I'm bound to the element variable
</div>
<!-- Bind to window properties -->
<svelte:window bind:scrollY bind:innerWidth />
<p>Scroll Y: {scrollY}</p>
<p>Window width: {innerWidth}</p>

Conditional Rendering

Conditional rendering syntax remains the same in Svelte 5:

<script>
let user = $state({ name: 'John', loggedIn: true });
let x = $state(7);
</script>
<!-- if/else -->
{#if user.loggedIn}
<h1>Welcome, {user.name}!</h1>
{:else}
<h1>Please log in</h1>
{/if}
<!-- else if -->
{#if x > 10}
<p>x is greater than 10</p>
{:else if x > 5}
<p>x is greater than 5</p>
{:else}
<p>x is 5 or less</p>
{/if}

Loops

Loop syntax remains the same in Svelte 5:

<script>
let items = $state([
{ id: 1, name: 'Apple', price: 1.5 },
{ id: 2, name: 'Banana', price: 0.8 },
{ id: 3, name: 'Cherry', price: 2.0 }
]);
let colors = $state(['red', 'green', 'blue']);
</script>
<!-- Basic each loop -->
{#each items as item}
<div>{item.name} - ${item.price}</div>
{/each}
<!-- With index -->
{#each colors as color, index}
<div>{index}: {color}</div>
{/each}
<!-- With key (important for performance) -->
{#each items as item (item.id)}
<div>{item.name} - ${item.price}</div>
{/each}
<!-- With else (when array is empty) -->
{#each items as item}
<div>{item.name}</div>
{:else}
<p>No items found</p>
{/each}

Shared State Across Components

In Svelte 5, use $state in .svelte.js or .svelte.ts files to share state across components:

state.svelte.js
export let count = $state(0);
export let user = $state({ name: '', email: '' });
// Derived values work too
export let doubled = $derived(count * 2);
ComponentA.svelte
<script>
import { count, user, doubled } from './state.svelte.js';
</script>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onclick={() => count++}>+</button>
ComponentB.svelte
<script>
import { count, user } from './state.svelte.js';
</script>
<p>Current count in B: {count}</p>
<input bind:value={user.name} placeholder="Name" />

Both components will automatically react to changes in the shared state!

Lifecycle

In Svelte 5, lifecycle functions are replaced by $effect with proper cleanup:

<script>
let photos = $state([]);
// Equivalent to onMount
$effect(() => {
console.log('Component mounted');
(async () => {
const res = await fetch('/api/photos');
photos = await res.json();
})();
});
// Cleanup (equivalent to onDestroy)
$effect(() => {
const interval = setInterval(() => {
console.log('Running...');
}, 1000);
return () => {
console.log('Component destroyed / effect cleanup');
clearInterval(interval);
};
});
</script>

Legacy Lifecycle Functions (Still Supported)

The old lifecycle functions still work for backwards compatibility:

<script>
import { onMount, onDestroy } from 'svelte';
let photos = $state([]);
onMount(async () => {
console.log('Component mounted');
const res = await fetch('/api/photos');
photos = await res.json();
});
onDestroy(() => {
console.log('Component destroyed');
});
</script>

Actions

Actions work similarly in Svelte 5, but event handling uses callbacks:

<script>
function clickOutside(node, callback) {
const handleClick = event => {
if (node && !node.contains(event.target)) {
callback?.();
}
};
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
};
}
let showModal = $state(false);
</script>
<button onclick={() => showModal = true}>Open Modal</button>
{#if showModal}
<div class="modal" use:clickOutside={() => showModal = false}>
<p>Click outside to close</p>
</div>
{/if}

Actions with Parameters

<script>
function tooltip(node, text) {
let tooltipElement;
function showTooltip() {
tooltipElement = document.createElement('div');
tooltipElement.textContent = text;
tooltipElement.className = 'tooltip';
document.body.appendChild(tooltipElement);
}
function hideTooltip() {
tooltipElement?.remove();
}
node.addEventListener('mouseenter', showTooltip);
node.addEventListener('mouseleave', hideTooltip);
return {
update(newText) {
text = newText;
},
destroy() {
node.removeEventListener('mouseenter', showTooltip);
node.removeEventListener('mouseleave', hideTooltip);
hideTooltip();
}
};
}
</script>
<button use:tooltip={'Click me for more info'}>Hover</button>

CSS Styling

Scoped Styles

Svelte automatically scopes CSS to components:

<style>
p {
color: blue; /* Only affects <p> in this component */
}
</style>

How it works:

  • Svelte adds unique classes (e.g., .svelte-xyz123)
  • CSS selectors are rewritten to include these classes
  • No CSS-in-JS runtime needed
  • True component isolation

Global Styles

<style>
:global(body) {
margin: 0;
}
</style>

Transitions and Animations

Transitions

Enter/leave animations:

<script>
import { fade, slide } from 'svelte/transition';
let visible = $state(true);
</script>
{#if visible}
<div transition:fade>Fades in and out</div>
{/if}

Animations

Animate position changes:

<script>
import { flip } from 'svelte/animate';
let items = $state([1, 2, 3]);
</script>
{#each items as item (item)}
<div animate:flip>Item {item}</div>
{/each}

Custom Transitions

function spin(node, { duration = 400 }) {
return {
duration,
css: t => `transform: rotate(${t * 360}deg)`
};
}

Advanced Utilities

tick() Function

tick() returns a promise that resolves after pending state changes are applied to the DOM:

import { tick } from 'svelte';
let count = $state(0);
async function increment() {
count++;
console.log(div.textContent); // Still shows old value
await tick();
console.log(div.textContent); // Now shows new value
}

Use cases:

  • Reading updated DOM measurements
  • Focus management after conditional rendering
  • Synchronizing with third-party libraries
  • Ensuring DOM reflects latest state

Svelte 5 Migration Notes

Key differences from Svelte 4:

  1. State: let count = 0let count = $state(0)
  2. Derived values: $: doubled = count * 2let doubled = $derived(count * 2)
  3. Effects: $: { console.log(count) }$effect(() => { console.log(count) })
  4. Props: export let namelet { name } = $props()
  5. Events: on:clickonclick
  6. Custom events: createEventDispatcher() → callback props
  7. Mutations: Reassignment required → direct mutation works
  8. Lifecycle: onMount/onDestroy$effect with cleanup

Key Runes Summary

  • $state() - Reactive state
  • $derived() - Computed values
  • $effect() - Side effects with optional cleanup
  • $props() - Component props
  • $state.raw() - Non-reactive state (for optimization)
  • $bindable() - Two-way bindable props

Performance Tips

  • Use {#key} blocks to force re-renders when needed
  • Implement proper loading states
  • Use bind:this sparingly
  • Prefer $derived over complex reactive calculations
  • Use $effect for side effects, not $derived
  • Use tick() when you need to wait for DOM updates
  • Leverage fine-grained reactivity with $state.raw for large, rarely-changing objects

Interview Questions and Answers

Q1: What is Svelte and how does it differ from React or Vue?

A1: Svelte is a compile-time framework that shifts work from the browser to the build step. Key differences:

  • No virtual DOM: Svelte compiles components to efficient imperative code that directly manipulates the DOM
  • No runtime: React/Vue ship a runtime library (~40-100KB), Svelte compiles to vanilla JavaScript
  • Smaller bundles: Svelte apps are typically smaller and faster
  • Simpler syntax: Less boilerplate, no hooks or lifecycle complexity
  • True reactivity: Updates are surgical, not diffing-based

Result: Better performance, smaller bundle sizes, and more intuitive code.

Q2: What are runes in Svelte 5 and why were they introduced?

A2: Runes are function-like symbols (starting with $) that provide explicit reactivity in Svelte 5. They were introduced to:

  • Improve clarity: Make reactivity explicit rather than magical
  • Better TypeScript support: Runes work better with type inference
  • Universal reactivity: Work in .svelte.js files, not just components
  • More control: Fine-grained control over reactive behavior
  • Performance: Enable better optimizations

Key runes: $state(), $derived(), $effect(), $props(). They replace let declarations, $: labels, and export let for props.

Q3: Explain the difference between stateandstate and derived.

A3: $state(): Creates mutable reactive state that can be updated directly

  • Use for values that change over time
  • Can be mutated: count++ or array.push(item)
  • Triggers re-renders when changed

$derived(): Creates computed values based on other reactive state

  • Read-only, cannot be directly modified
  • Automatically updates when dependencies change
  • Use for calculations and transformations
let count = $state(0); // Mutable state
let doubled = $derived(count * 2); // Computed from count

Q4: When should you use effectvseffect vs derived?

A4: $derived(): For computing values

  • Returns a value used in your template or logic
  • Synchronous calculations
  • Example: let fullName = $derived(firstName + ' ' + lastName)

$effect(): For side effects

  • Doesn’t return a value (or returns cleanup function)
  • Async operations, subscriptions, logging, DOM manipulation
  • Example: Updating document title, fetching data, setting up timers

Rule: If you’re computing a value, use $derived. If you’re performing an action, use $effect.

Q5: How does reactivity work in Svelte 5 compared to Svelte 4?

A5: Svelte 4: Implicit reactivity through assignments

let count = 0;
$: doubled = count * 2; // Reactive declaration

Svelte 5: Explicit reactivity through runes

let count = $state(0);
let doubled = $derived(count * 2);

Key improvements in Svelte 5:

  • Direct mutations work: array.push() triggers updates (no reassignment needed)
  • Better TypeScript: Proper type inference
  • Module-level state: Use runes in .svelte.js files
  • Clearer intent: Explicit about what’s reactive

Q6: What is the $props rune and how does it improve upon export let?

A6: $props() is the Svelte 5 way to declare component props, replacing export let:

Svelte 4:

export let name;
export let age = 25;

Svelte 5:

let { name, age = 25 } = $props();

Benefits:

  • Destructuring: Natural JavaScript syntax
  • Rest props: let { name, ...rest } = $props()
  • TypeScript: Better type checking
  • Clearer intent: Explicitly declares component API
  • Consistency: Matches standard JavaScript patterns

Q7: How do you handle events in Svelte 5?

A7: Svelte 5 uses standard HTML event attributes instead of on: directives:

Svelte 4:

<button on:click={handleClick}>Click</button>

Svelte 5:

<button onclick={handleClick}>Click</button>

For custom events, use callback props instead of createEventDispatcher():

// Child component
let { onMessage } = $props();
<button onclick={() => onMessage?.('hello')}>Send</button>
// Parent component
<Child onMessage={(text) => console.log(text)} />

This aligns with standard DOM events and is more intuitive.

Q8: What is the purpose of $effect cleanup functions?

A8: Cleanup functions prevent memory leaks and clean up side effects:

$effect(() => {
const interval = setInterval(() => {
console.log('tick');
}, 1000);
// Cleanup runs when:
// - Component unmounts
// - Effect re-runs (dependencies change)
return () => {
clearInterval(interval);
};
});

Common cleanup use cases:

  • Clear intervals/timeouts
  • Remove event listeners
  • Cancel subscriptions
  • Abort fetch requests
  • Close WebSocket connections

Without cleanup, these would continue running after component unmounts, causing memory leaks.

Q9: How does Svelte handle CSS scoping?

A9: Svelte automatically scopes CSS to components:

<style>
p {
color: blue; /* Only affects <p> in this component */
}
</style>

How it works:

  • Svelte adds unique classes (e.g., .svelte-xyz123)
  • CSS selectors are rewritten to include these classes
  • No CSS-in-JS runtime needed
  • True component isolation

Global styles:

<style>
:global(body) {
margin: 0;
}
</style>

Benefits: No className props, automatic cleanup when component removed, better performance than CSS-in-JS.

Q10: What is the difference between bind:value and onchange?

A10:

bind:value: Two-way binding, automatically syncs state with input

<script>
let name = $state('');
</script>
<input bind:value={name} />
<p>Hello {name}!</p>

onchange: One-way, manual event handling

<script>
let name = $state('');
</script>
<input value={name} onchange={(e) => name = e.target.value} />

Use bind: for:

  • Simple form inputs
  • Two-way data flow
  • Less boilerplate

Use onchange for:

  • Complex validation
  • Debouncing
  • Custom transformation logic

bind: is syntactic sugar that’s more concise for common cases.

This refresher covers the essential concepts of Svelte 5. Svelte 5’s runes provide more explicit reactivity and better performance, making it an excellent choice for building modern web applications.