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

Svelte 4 vs Svelte 5: a practical guide for developers

What changed and why.

Expona Admin7 min read

Svelte 4 vs Svelte 5: a practical guide for developers

Svelte 5 represents a fundamental shift in how we write reactive components, moving away from compiler magic toward explicit, predictable patterns. If you're managing a production codebase built on Svelte 4, understanding these changes isn't optional—it's essential for planning your migration strategy and making informed decisions about new projects. This guide walks through the core differences between Svelte 4 and Svelte 5, showing you what changed, why it matters, and how to adapt your workflow.

Runes replace let and $: reactivity

Svelte 4's reactivity model relied on compiler heuristics to detect when a variable should trigger updates. You'd write let count = 0 and use the assignment operator to mutate it, letting the compiler figure out what needed updating. Svelte 5 replaces this with runes—explicit JavaScript functions that make reactivity intent clear and unambiguous.

The most common rune is $state, which replaces plain let declarations for reactive variables. Here's a concrete example:

typescript
// Svelte 4
<script>
  let count = 0;
  let doubled = count * 2;

  $: doubled = count * 2;

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  {count} (doubled: {doubled})
</button>

In Svelte 5, this becomes:

typescript
// Svelte 5
<script>
  let count = $state(0);
  let doubled = $derived(count * 2);

  function increment() {
    count += 1;
  }
</script>

<button onclick={increment}>
  {count} (doubled: {doubled})
</button>

Notice two things here: $state() marks reactive state explicitly, and $derived() replaces reactive declarations. This is clearer because anyone reading the code sees immediately which variables are reactive and which are derived. The compiler no longer needs to guess whether let count = 0 in a component is reactive state or a constant.

For more complex reactive computations, Svelte 5 offers $derived.by() when you need a function body:

typescript
// Svelte 5
<script>
  let items = $state([1, 2, 3, 4, 5]);
  
  let sum = $derived.by(() => {
    console.log('recalculating sum');
    return items.reduce((a, b) => a + b, 0);
  });
</script>

In production codebases with dozens of components, this explicit syntax reduces bugs. We've seen cases where developers weren't sure whether a reactive statement was executing, leading to stale derived values. Runes eliminate that ambiguity entirely.

Snippets replace slots

Slots in Svelte 4 allowed child components to pass content to parents, but they required context and mental overhead when building reusable patterns. Svelte 5 introduces snippets, which are more like render functions or partial templates that you pass as props.

Consider a card component in Svelte 4:

typescript
// Svelte 4 - Card.svelte
<div class="card">
  <div class="header">
    <slot name="header" />
  </div>
  <div class="body">
    <slot />
  </div>
  <div class="footer">
    <slot name="footer" />
  </div>
</div>

<style>
  .card { border: 1px solid #ccc; padding: 1rem; }
</style>

Using it required nesting JSX-like syntax in the parent:

typescript
// Svelte 4 - using Card
<Card>
  <div slot="header">My Title</div>
  <p>Card content goes here</p>
  <div slot="footer">Footer text</div>
</Card>

In Svelte 5, you'd define the card using snippets:

typescript
// Svelte 5 - Card.svelte
<script>
  let { header, body, footer } = $props();
</script>

<div class="card">
  <div class="header">
    {@render header?.()}
  </div>
  <div class="body">
    {@render body?.()}
  </div>
  <div class="footer">
    {@render footer?.()}
  </div>
</div>

And pass snippets as props:

typescript
// Svelte 5 - using Card with snippets
<script>
  import Card from './Card.svelte';

  #snippet header_content() {
    <h2>My Title</h2>
  </snippet>

  #snippet body_content() {
    <p>Card content goes here</p>
  </snippet>

  #snippet footer_content() {
    <p>Footer text</p>
  </snippet>
</script>

<Card {header: header_content} {body: body_content} {footer: footer_content} />

Snippets are more powerful because they're just functions that return markup. They accept parameters, maintain closure over parent scope, and are easier to type-check. In real-world e-commerce projects, we've used snippets to build flexible list renderers where each row item is a snippet, making it trivial to pass down item data and handle actions within the snippet body.

Callback props replace dispatchers

Svelte 4 relied on event dispatch for child-to-parent communication. You'd emit events using createEventDispatcher and listen with on: directives. This pattern introduced indirection and made prop drilling a common problem when you wanted to pass callbacks through multiple component layers.

Here's the Svelte 4 approach:

typescript
// Svelte 4 - Button.svelte
<script>
  import { createEventDispatcher } from 'svelte';
  
  const dispatch = createEventDispatcher();
  
  function handleClick() {
    dispatch('click', { timestamp: Date.now() });
  }
</script>

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

In the parent:

typescript
// Svelte 4 - using Button
<Button on:click={(e) => console.log(e.detail.timestamp)} />

Svelte 5 moves to explicit callback props, which are just functions passed as properties:

typescript
// Svelte 5 - Button.svelte
<script>
  let { onclick } = $props();
</script>

<button {onclick}>Click me</button>

Usage in the parent becomes straightforward:

typescript
// Svelte 5 - using Button
<Button onclick={() => console.log('clicked')} />

This aligns Svelte with native HTML semantics. The onclick attribute on a button is a callback function, not an event dispatcher. We've found this reduces debugging friction in teams because the data flow is explicit and the pattern matches what developers already know from React or vanilla JavaScript.

For form submissions and more complex interactions, Svelte 5 also provides the onsubmit pattern, making form handling more predictable:

typescript
// Svelte 5 - Form.svelte
<script>
  let { onsubmit } = $props();
</script>

<form onsubmit={(e) => {
  e.preventDefault();
  onsubmit(new FormData(e.currentTarget));
}}>
  <input type="text" name="username" />
  <button type="submit">Submit</button>
</form>

Better performance and TypeScript

Svelte 5 improves performance by making the compiler's job simpler. Since reactivity is now explicit through runes, the compiler doesn't need to scan for assignments or guess which statements are reactive declarations. This leads to smaller output bundles and faster component updates.

TypeScript support improved significantly as well. Runes make it possible for TypeScript to understand reactive types at compile time. Props are now strictly typed through $props(), which TypeScript can analyze more deeply:

typescript
// Svelte 5 - fully typed component
<script lang="ts">
  interface CardProps {
    title: string;
    count: number;
    onClose?: (reason: string) => void;
  }

  let { title, count, onClose }: CardProps = $props();
</script>

<div class="card">
  <h3>{title}</h3>
  <p>Count: {count}</p>
  <button onclick={() => onClose?.('user clicked')}>
    Close
  </button>
</div>

In production SaaS applications handling real-time trading data or ride-hailing logistics, this type safety catches prop mismatches at development time rather than in production logs. The performance gains matter too—when rendering large lists or frequent updates, explicit reactivity means fewer unnecessary re-evaluations.

Common mistakes

  • Forgetting $props() in components: Svelte 5 components don't automatically expose props. You must destructure them with $props(). Simply declaring a variable won't make it a prop.
  • Mixing old and new patterns: During migration, it's tempting to use both let and $state() in the same component. This creates confusion. Pick one pattern per component and migrate systematically.
  • Passing snippets as JSX children: Snippets aren't passed through the children slot. They're explicit props. Don't try to pass them like <Card><snippet>...</snippet></Card>; instead, define the snippet in the parent and pass it as a prop.
  • Not updating event handlers: on:click still works in Svelte 5 for native HTML elements, but custom components should use callback props. Mixing both in a custom component creates inconsistency.
  • Assuming $derived runs synchronously: Derived values compute lazily, only when accessed. If you need immediate computation for side effects, use event handlers or lifecycle functions instead.

Key takeaways

  • Runes ($state, $derived) replace implicit reactivity detection, making component behavior explicit and easier to reason about.
  • Snippets are more flexible than slots for passing dynamic template content, with better TypeScript support and parameter passing.
  • Callback props replace event dispatchers, aligning Svelte with native HTML and making data flow clearer in component hierarchies.
  • Explicit prop typing through $props() combined with TypeScript catches errors earlier and improves IDE support.
  • Performance improves because the compiler doesn't need to infer reactivity; it can generate tighter code and optimize more aggressively.
  • Migration from Svelte 4 to 5 requires systematic refactoring, but the clarity and type safety gains make it worth the effort in production codebases.
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